"""
Implements functionality related to ``topology.yml``. Exposes
:data:`topology_config`, which contains the data derived from topology.yml``.

Provides constructors for the following YAML tags (on ``yaml.SafeLoader``):

* ``!FieldRole`` (Scalar -> :class:`~.config_dataclasses.NG911Field`) - Given a field's
  :attr:`~.config_dataclasses.NG911Field.role`, returns the corresponding field object
* ``!DomainName`` (Scalar -> :class:`~.config_dataclasses.NG911Domain`) - Given a domain's
  :attr:`~.config_dataclasses.NG911Domain.name`, returns the corresponding field object
"""
from collections.abc import Collection
from itertools import chain
import logging
import os
from pathlib import Path
from typing import Optional, Self, TypedDict, NotRequired, cast, ClassVar, LiteralString, Final, final, Literal

import arcpy
import attrs
import pandas as pd
import yaml
from gdbschema.constants import esriTopologyRuleType as ETRT
# noinspection PyProtectedMember
from pandas._libs.missing import NAType

from .misc import quote
from .iterablenamespace import FrozenList, FrozenDict
from .config_dataclasses import NG911FeatureClass, NG911Field, NG911Domain
from .session import config


__all__ = ["TopologyRule", "topology_config"]

_logger = logging.getLogger(__name__)

# _TOPOLOGY_YAML_PATH = join(__file__, *([".."] * 3), "topology.yml")
_TOPOLOGY_YAML_PATH: Final[Path] = Path(__file__).parent.parent.parent / "topology.yml"
"""Path to ``topology.yml``."""

_RDS: Final[str] = config.gdb_info.required_dataset_name
"""Name of the :term:`required feature dataset`."""

_ODS: Final[str] = config.gdb_info.optional_dataset_name
"""Name of the :term:`optional feature dataset`."""

_TOPOEXCEPT: Final[str] = config.fields.topoexcept.name
"""Name of the :ng911field:`topoexcept` field."""

_SUBMIT: Final[str] = config.fields.submit.name
"""Name of the :ng911field:`submit` field."""

_ESRI_RULE_NAMES: Final[frozenset[str]] = frozenset({str(r.value) for r in ETRT.enum_code_lookup()})  # e.g., "Must Not Have Dangles (Line)"
"""Set of Esri topology rule names, e.g., ``Must Not Have Dangles (Line)``."""

_ESRI_RULE_CODES: Final[frozenset[str]] = frozenset({r.name for r in ETRT.enum_code_lookup()})  # e.g., "esriTRTLineNoDangles"
"""Set of Esri topology rule codes, e.g., ``esriTRTLineNoDangles``."""


class _OneMemberTopologyRuleDict(TypedDict):
    rule: str
    member: str

class _TwoMemberTopologyRuleDict(TypedDict):
    member1: str
    rule: str
    member2: str

class _TopologyConfigDict(TypedDict):
    exception_field: NG911Field
    exception_domain: NG911Domain
    required_dataset_topology_name: str
    optional_dataset_topology_name: str
    required_dataset_rules: list[_OneMemberTopologyRuleDict | _TwoMemberTopologyRuleDict]
    optional_dataset_rules: NotRequired[list[_OneMemberTopologyRuleDict | _TwoMemberTopologyRuleDict]]


@final
@attrs.frozen
class TopologyRule:
    """Class representing a topology rule specified by the Standards."""

    _registered_rules: ClassVar[list[Self]] = []
    """List of instances of this class, which are automatically registered
    after initialization."""

    dataset: str = attrs.field(validator=attrs.validators.in_({_RDS, _ODS}))
    """The name of the feature dataset containing the feature class(es) to
    which the rule applies."""

    member1: NG911FeatureClass = attrs.field()
    """The first member ("origin") feature class included in the rule."""

    rule: str = attrs.field(validator=attrs.validators.in_(_ESRI_RULE_NAMES))
    """The name (from :data:`_ESRI_RULE_NAMES`) of the topology rule."""

    member2: NG911FeatureClass | None = attrs.field(default=None)
    """The second member ("destination") feature class included in the rule."""

    __match_args__ = ("dataset", "member1", "rule", "member2")

    def __attrs_post_init__(self):
        self.__class__._registered_rules.append(self)

    @classmethod
    def from_dict(cls, dataset: str, data: _OneMemberTopologyRuleDict | _TwoMemberTopologyRuleDict) -> Self:
        match data:
            case {"rule": rule, "member": member}:
                return cls(dataset, config.feature_classes[member], rule)
            case {"member1": member1, "rule": rule, "member2": member2}:
                return cls(dataset, config.feature_classes[member1], rule, config.feature_classes[member2])
            case _:
                raise ValueError("Unexpected data.")

    @classmethod
    def as_df(cls,
              members_as: Literal["OBJECT", "ROLE", "NAME"] = "OBJECT",
              rule_as: Literal["DESCRIPTION", "ETRT"] = "DESCRIPTION"
              ) -> pd.DataFrame:
        """
        Returns all registered topology rules as a ``DataFrame`` with three
        columns: ``member1``, ``rule``, and ``member2``.

        The ``member1`` and ``member2`` columns will have the dtype ``object``
        if *members_as* is ``OBJECT`` or the dtype ``string[python]`` if
        *members_as* is ``ROLE`` (meaning the :attr:`.NG911FeatureClass.role`
        attribute) or ``NAME`` (meaning the :attr:`.NG911FeatureClass.name`
        attribute).

        The ``rule`` column will have the dtype ``string[python]``.

        :param Literal["OBJECT", "ROLE", "NAME"] members_as: How the member
            should be represented.
        :param Literal["DESCRIPTION", "ETRT"] rule_as: How the rules should be
            represented.
        :return pd.DataFrame: The registered topology rules as a ``DataFrame``.
        """
        dtypes = {
            "member1": {"OBJECT": object, "ROLE": pd.StringDtype(), "NAME": pd.StringDtype()}[members_as],
            "rule": pd.StringDtype(),
            "member2": {"OBJECT": object, "ROLE": pd.StringDtype(), "NAME": pd.StringDtype()}[members_as],
        }
        series_list: list[pd.Series] = [rule.as_series(members_as, rule_as) for rule in cls._registered_rules]
        return pd.DataFrame(series_list).astype(dtypes)

    def as_series(self,
                  members_as: Literal["OBJECT", "ROLE", "NAME"] = "OBJECT",
                  rule_as: Literal["DESCRIPTION", "ETRT"] = "DESCRIPTION"
                  ) -> pd.Series:
        member1 = self.member1 if members_as == "OBJECT" else self.member1.role if members_as == "ROLE" else self.member1.name if members_as == "NAME" else None
        rule = self.rule if rule_as == "DESCRIPTION" else self.rule_code if rule_as == "ETRT" else None
        member2 = pd.NA if self.member2 is None else self.member2 if members_as == "OBJECT" else self.member2.role if members_as == "ROLE" else self.member2.name if members_as == "NAME" else None
        if None in {member1, rule, member2}:
            raise RuntimeError("\n\t".join(["None of the following should be None:", f"member1: {member1}", f"rule: {rule}", f"member2: {member2}"]))
        return pd.Series({
            "member1": member1,
            "rule": rule,
            "member2": member2,
        })

    @property
    def rule_code(self) -> str:
        """
        Returns the ``esriTopologyRuleType`` code for the rule.

        .. seealso::

            :data:`_ESRI_RULE_CODES`
        """
        return ETRT(self.rule).name

    @property
    def members(self) -> tuple[NG911FeatureClass] | tuple[NG911FeatureClass, NG911FeatureClass]:
        """Returns a tuple of the member or members of the rule."""
        if self.member2:
            return self.member1, self.member2
        else:
            return self.member1,

    @property
    def name1(self) -> str:
        """Returns the :attr:`~NG911FeatureClass.name` of :attr:`member1`."""
        return self.member1.name

    @property
    def name2(self) -> str | None:
        """Returns the :attr:`~NG911FeatureClass.name` of :attr:`member2`."""
        return self.member2.name if self.member2 else None

    def has_member(self, feature_class: NG911FeatureClass) -> bool:
        """Returns whether a given feature class is a member of the rule."""
        return feature_class in self.members

    def add_rule_to_topology(self, topology_name: str, manager: Optional[arcpy.EnvManager] = None) -> arcpy.Result:
        """Adds the rule to a topology using ``arcpy``."""
        with manager or arcpy.EnvManager():
            return arcpy.management.AddRuleToTopology(topology_name, self.rule, self.name1, None, self.name2, None)


def _construct_field_from_role(loader: yaml.SafeLoader, node: yaml.Node) -> NG911Field:
    assert isinstance(node, yaml.ScalarNode)
    field_role: str = loader.construct_scalar(node)
    return config.fields[field_role]

def _construct_domain_from_name(loader: yaml.SafeLoader, node: yaml.Node) -> NG911Domain:
    assert isinstance(node, yaml.ScalarNode)
    domain_name: str = loader.construct_scalar(node)
    return config.get_domain_by_name(domain_name)

yaml.add_constructor("!FieldRole", _construct_field_from_role, yaml.SafeLoader)
yaml.add_constructor("!DomainName", _construct_domain_from_name, yaml.SafeLoader)


@attrs.frozen
class _NG911TopologyConfig(yaml.YAMLObject):

    yaml_tag = "!TopologyConfig"
    yaml_loader = yaml.SafeLoader

    exception_field: NG911Field
    exception_domain: NG911Domain
    required_dataset_topology_name: str
    optional_dataset_topology_name: str
    required_dataset_rules: FrozenList[TopologyRule]
    optional_dataset_rules: FrozenList[TopologyRule]
    exception_mapping: ClassVar[FrozenDict[LiteralString, frozenset[str]]] = FrozenDict({
        "DANGLE_EXCEPTION": (_dangle_exc := frozenset({ETRT.esriTRTLineNoDangles.name})),
        "INSIDE_EXCEPTION": (_inside_exc := frozenset({ETRT.esriTRTLineInsideArea.name, ETRT.esriTRTPointProperlyInsideArea.name})),
        "BOTH_EXCEPTION": frozenset({*_dangle_exc, *_inside_exc}),
        "NO_EXCEPTION": frozenset()
    })
    address_point_allowed_values: ClassVar[frozenset[LiteralString]] = frozenset({"NO_EXCEPTION", "INSIDE_EXCEPTION"})

    @classmethod
    def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.Node):
        assert isinstance(node, yaml.MappingNode)
        mapping: _TopologyConfigDict = cast(_TopologyConfigDict, loader.construct_mapping(node, deep=True))
        # exception_field = config.fields[mapping["exception_field_role"]]
        exception_field = mapping["exception_field"]
        # exception_domain = config.get_domain_by_name(mapping["exception_domain_name"])
        exception_domain = mapping["exception_domain"]
        required_dataset_topology_name = mapping["required_dataset_topology_name"]
        optional_dataset_topology_name = mapping["optional_dataset_topology_name"]
        required_ds_rule_data = mapping["required_dataset_rules"]
        required_dataset_rules = FrozenList([
            TopologyRule.from_dict(_RDS, rule_params) for rule_params in required_ds_rule_data
        ])
        optional_ds_rule_data = mapping.get("optional_dataset_rules")
        optional_dataset_rules = FrozenList(
            [
                TopologyRule.from_dict(_ODS, rule_params)
                for rule_params in optional_ds_rule_data
            ]
            if optional_ds_rule_data else []
        )

        return cls(exception_field, exception_domain, required_dataset_topology_name, optional_dataset_topology_name, required_dataset_rules, optional_dataset_rules)

    # @property
    # def required_dataset_topology_name(self) -> str:
    #     """Returns the name of the topology item for the required dataset."""
    #     return f"{_RDS}_Topology"
    #
    # @property
    # def optional_dataset_topology_name(self) -> str:
    #     """Returns the name of the topology item for the optional dataset."""
    #     return f"{_ODS}_Topology"

    @property
    def required_dataset_members(self) -> frozenset[NG911FeatureClass]:
        """Returns a ``frozenset`` with all ``NG911FeatureClass`` objects that
        are involved in ``self.required_dataset_rules``."""
        return frozenset(chain(*(rule.members for rule in self.required_dataset_rules)))

    @property
    def optional_dataset_members(self) -> frozenset[NG911FeatureClass]:
        """Returns a ``frozenset`` with all ``NG911FeatureClass`` objects that
        are involved in ``self.optional_dataset_rules``."""
        return frozenset(chain(*(rule.members for rule in self.optional_dataset_rules)))

    # def add_members_to_topology(self, gdb: str, *, required_ds_topology: Optional[str] = None, optional_ds_topology: Optional[str] = None) -> tuple[int, int]:
    # COMMENTED OUT BECAUSE UNUSED
    #     """
    #     Adds all missing members to the required and/or optional datasets'
    #     topologies.
    #
    #     :param gdb: Path to the NG911 geodatabase
    #     :type gdb: str
    #     :param required_ds_topology: Name of the topology in the required
    #         feature dataset
    #     :type required_ds_topology: str
    #     :param optional_ds_topology: Name of the topology in the optional
    #         feature dataset
    #     :type optional_ds_topology: str
    #     :return: Number of feature classes added to the required and optional
    #         datasets' topologies, respectively
    #     :rtype: tuple[int, int]
    #     """
    #     add_counts: dict[str, int] = {}
    #     for ds, topology, members in (
    #             (_RDS, required_ds_topology, self.required_dataset_members),
    #             (_ODS, optional_ds_topology, self.optional_dataset_members)
    #     ):
    #         if not topology:
    #             add_counts[ds] = 0
    #             continue  # Argument not provided; skip
    #         add_count: int = 0
    #         with arcpy.EnvManager(workspace=os.path.join(gdb, ds)):
    #             describe_obj = arcpy.Describe(topology)
    #             for fc_name in {fc.name for fc in members} - set(describe_obj.featureClassNames):
    #                 arcpy.management.AddFeatureClassToTopology(topology, fc_name)
    #                 add_count += 1
    #         add_counts[ds] = add_count
    #     return add_counts[_RDS], add_counts[_ODS]

    def get_rule(self, member1: NG911FeatureClass | NAType | None, rule_code: ETRT | str, member2: Optional[NG911FeatureClass | NAType] = None) -> TopologyRule | None:
        """
        Searches through the known rules for a rule matching the arguments and
        returns it. Returns ``None`` if no rule is found.

        :param member1: Feature class representing the first member
        :type member1: NG911FeatureClass | NAType | None
        :param rule_code: The string code (e.g., esriTRTLineNoDangles), the
            string description (e.g., "Must Not Have Dangles (Line)"), or the
            ``esriTopologyRuleType`` instance representing the rule
        :type rule_code: esriTopologyRuleType | str
        :param member2: Feature class representing the second member, or
            ``None`` (or ``pandas.NA``, which will be treated the same as
            ``None``) if not applicable; default ``None``
        :type member2: NG911FeatureClass | NAType | None
        :return: The rule matching the arguments, or ``None`` if no such rule
            could be found
        :rtype: TopologyRule | None
        """
        member1 = None if pd.isna(member1) else member1
        member2 = None if pd.isna(member2) else member2
        dataset: str
        if member1 and member2 and member1.dataset == member2.dataset:
            dataset = member1.dataset
        elif member1 and member2 and member1.dataset != member2.dataset:
            raise ValueError(f"Feature classes with role '{member1.role}' belongs to dataset '{member1.dataset}', but feature classes with role '{member2.role}' belongs to dataset '{member2.dataset}'.")
        elif member1:
            dataset = member1.dataset
        elif member2:
            # ...but no member1, which can happen in certain cases, apparently.
            # This is a possibility when this method's arguments are derived
            # from the attributes of a topology error feature class. There
            # isn't really a choice other than to return None, because the
            # existence of member1 is implied but its actual value is unknown.
            dataset = member2.dataset
        else:
            return None

        rules: FrozenList[TopologyRule] = {
            _RDS: self.required_dataset_rules,
            _ODS: self.optional_dataset_rules,
        }[dataset]

        if isinstance(rule_code, ETRT):
            rule_code: str = rule_code.name
        elif rule_code in _ESRI_RULE_CODES:  # Something like "esriTRTLineNoDangles"
            pass
        elif rule_code in _ESRI_RULE_NAMES:  # Something like "Must Not Have Dangles (Line)"
            rule_code = ETRT(rule_code).name
        else:
            raise ValueError(f"Argument for 'rule_code' must be an instance of esriTopologyRuleType or a string matching a name or value thereof; got '{rule_code}'.")

        for rule in rules:
            if all([
                rule.dataset == dataset,
                rule.member1 == member1,
                rule.rule_code == rule_code,
                (rule.member2 == member2) or (not rule.member2 and not member2),
            ]):
                return rule

        # raise ValueError(f"No rule matches the arguments (member1.name = {member1.name}, rule_code = {rule_code}, member2.name = {member2.name}).")
        return None

    # def enrich_error_df(self, error_df: pd.DataFrame, member1_df: pd.DataFrame, member2_df: Optional[pd.DataFrame] = None, *, overwrite_columns: bool = False) -> pd.DataFrame:
    # def enrich_error_df(self, error_df: pd.DataFrame, members: Collection[pd.DataFrame], *, overwrite_columns: bool = False) -> pd.DataFrame:
    def enrich_error_df(self, error_df: pd.DataFrame, members: Collection[pd.DataFrame], fill_na_submit: Optional[bool] = None) -> pd.DataFrame:
        """
        Processes a topology error DataFrame (from the Export Topology Errors
        geoprocessing tool). Unless it is empty, the returned
        ``pandas.DataFrame`` will have the following columns:

        .. list-table::
            :header-rows: 1

            * - Column Name
              - ``dtype``
            * - ``OriginObjectClassName``
              - ``string``
            * - ``OriginObjectID``
              - ``Int32``
            * - ``DestinationObjectClassName``
              - ``string``
            * - ``DestinationObjectID``
              - ``Int32``
            * - ``RuleType``
              - ``string``
            * - ``RuleDescription``
              - ``string``
            * - ``SHAPE``
              - ``geometry``
            * - ``OriginObjectClassObject``
              - ``object``
            * - ``OriginObjectNGUID``
              - ``string``
            * - ``OriginObjectSubmit``
              - ``boolean``
            * - ``OriginObjectExceptions``
              - ``object``
            * - ``DestinationObjectClassObject``
              - ``object``
            * - ``DestinationObjectNGUID``
              - ``string``
            * - ``DestinationObjectSubmit``
              - ``boolean``
            * - ``DestinationObjectExceptions``
              - ``object``
            * - ``RuleObject``
              - ``object``
            * - ``IsException``
              - ``boolean``

        :param error_df: Topology error DataFrame
        :param members: All relevant member feature classes as DataFrames
        :param fill_na_submit: Value to assume for invalid values of
            :ng911field:`submit`; if ``None``, a ValueError will be raised if
            any features involved in an error have an invalid or missing value
            for that attribute; default None
        :return: Copy of topology error DataFrame with additional columns
        """
        _logger.debug(f"Enriching topology error DataFrame with {len(error_df)} row(s).")
        members: dict[NG911FeatureClass, pd.DataFrame] = {member_df.attrs["feature_class"]: member_df for member_df in members}
        oid_names: dict[NG911FeatureClass, str] = {fc: member_df.attrs["oid_name"] for fc, member_df in members.items()}

        error_df_expected_columns = {"OriginObjectClassName", "OriginObjectID", "DestinationObjectClassName", "DestinationObjectID", "RuleType", "RuleDescription"}
        if missing_columns := (error_df_expected_columns - set(error_df.columns)):
            raise ValueError(f"The following columns are missing from error_df: {', '.join(missing_columns)}")

        error_df = error_df.copy()
        error_df["OriginObjectClassName"].replace("", pd.NA, inplace=True)
        error_df["DestinationObjectClassName"].replace("", pd.NA, inplace=True)

        if error_df.empty:
            _logger.info("Empty topology error DataFrame; returning.")
            return error_df  # TODO: Add other columns

        # from itertools import count  # UNCOMMENT FOR DEBUGGING
        # DEBUG_HTML_DIR = Path(__file__).parent.parent.parent  # UNCOMMENT FOR DEBUGGING
        # _counter = count(1)  # UNCOMMENT FOR DEBUGGING
        # make_file_name = lambda name_detail: DEBUG_HTML_DIR / f"DEBUG_enrich_error_df_{next(_counter):02d}_{name_detail}.html"  # UNCOMMENT FOR DEBUGGING
        # error_df.drop(columns=["SHAPE"]).to_html(make_file_name("error_df_initial"))  # UNCOMMENT FOR DEBUGGING

        all_feature_df: pd.DataFrame = pd.concat([
            pd.DataFrame({
                "MergeFeatureClassName": pd.Series([fc.name] * len(member_df), index=member_df.index, dtype=pd.StringDtype()),  # Set the FC name for all rows
                "MergeObjectID": member_df[oid_names[fc]],  # Bring in the OID column from the FC DF
                "FeatureClassObject": pd.Series([fc] * len(member_df), index=member_df.index, dtype=object),  # Set the FC object for all rows
                "NGUID": member_df[member_df.attrs["nguid_name"]],  # Bring in the NGUID column from the FC DF
                "Submit": member_df[_SUBMIT].map({"Y": True, "N": False}),  # Bring in the submit column from the FC DF and map its values to True/False
                "Exceptions": member_df[_TOPOEXCEPT].apply(lambda val: self.exception_mapping.get(val, frozenset()))
                    if _TOPOEXCEPT in member_df.columns
                    else [frozenset()] * len(member_df)
            })
            for fc, member_df  # Iterate over all (member) feature class data frames
            in members.items()
        ]).astype({
            "MergeFeatureClassName": pd.StringDtype(),
            "MergeObjectID": pd.Int64Dtype(),
            "FeatureClassObject": object,
            "NGUID": pd.StringDtype(),
            "Submit": pd.BooleanDtype(),
            "Exceptions": object
        }).set_index(["MergeFeatureClassName", "MergeObjectID"])

        if fill_na_submit is None and all_feature_df["Submit"].isna().any():
            raise ValueError(f"Argument for 'fill_na_submit' was None, but at least one feature had an invalid or missing '{_SUBMIT}' attribute.")
        elif isinstance(fill_na_submit, bool):
            all_feature_df["Submit"].fillna(value=fill_na_submit, inplace=True)
        else:
            raise TypeError(f"Argument for 'fill_na_submit' should be None or a bool, got value '{fill_na_submit}'.")

        _logger.debug(f"Built all_feature_df; contains {len(all_feature_df)} row(s) and the column(s): {', '.join(quote(all_feature_df.columns))}.")
        # all_feature_df.to_html(make_file_name("all_feature_df_initial"))  # UNCOMMENT FOR DEBUGGING

        error_df = error_df.merge(
            all_feature_df.rename(columns={
                "FeatureClassObject": "OriginObjectClassObject",
                "NGUID": "OriginObjectNGUID",
                "Submit": "OriginObjectSubmit",
                "Exceptions": "OriginObjectExceptions"
            }),
            how="left",
            left_on=["OriginObjectClassName", "OriginObjectID"],
            right_index=True
        )

        _logger.debug(f"Completed origin feature merge. DataFrame error_df now has column(s): {', '.join(quote(error_df.columns))}.")
        # error_df.drop(columns=["SHAPE"]).to_html(make_file_name("error_df_after_origin_merge"))  # UNCOMMENT FOR DEBUGGING

        error_df = error_df.merge(
            all_feature_df.rename(columns={
                "FeatureClassObject": "DestinationObjectClassObject",
                "NGUID": "DestinationObjectNGUID",
                "Submit": "DestinationObjectSubmit",
                "Exceptions": "DestinationObjectExceptions"
            }),
            how="left",
            left_on=["DestinationObjectClassName", "DestinationObjectID"],
            right_index=True
        )

        _logger.debug(f"Completed feature merges. DataFrame error_df now has column(s): {', '.join(quote(error_df.columns))}.")
        # error_df.drop(columns=["SHAPE"]).to_html(make_file_name("error_df_after_feature_merges"))  # UNCOMMENT FOR DEBUGGING

        error_df["RuleObject"] = error_df.apply(
            lambda row: self.get_rule(row["OriginObjectClassObject"] or pd.NA, row["RuleType"], row["DestinationObjectClassObject"] or pd.NA) or pd.NA,
            axis=1
        )

        _logger.debug(f"Added 'RuleObject' column. DataFrame error_df now has column(s): {', '.join(quote(error_df.columns))}.")
        # error_df.drop(columns=["SHAPE"]).to_html(make_file_name("error_df_with_ruleobject"))  # UNCOMMENT FOR DEBUGGING

        error_df["IsException"] = error_df.apply(
            lambda row:
                (pd.notna(oo_exc := row["OriginObjectExceptions"]) and row["RuleType"] in oo_exc)  # oo_exc -> [o]rigin [o]bject [exc]eptions
                or (pd.notna(do_exc := row["DestinationObjectExceptions"]) and row["RuleType"] in do_exc),  # do_exc -> [d]estination [o]bject [exc]eptions
            axis=1
        ).astype(pd.BooleanDtype())

        _logger.debug(f"Added 'IsException' column. DataFrame error_df now has column(s): {', '.join(quote(error_df.columns))}.")
        # column_details = "\n".join(f"- ``{col_name}`` (``{error_df.dtypes[col_name]}``)" for col_name in error_df.columns)
        # _logger.debug(f"Column details:\n{column_details}")
        # error_df.drop(columns=["SHAPE"]).to_html(make_file_name("error_df_final_with_isexception"))  # UNCOMMENT FOR DEBUGGING

        return error_df
        #
        # for fc, member_df in members.items():
        #     all_feature_df




        # member1_exceptions: pd.Series
        # if _TOPOEXCEPT in member1_df.columns:
        #     member1_exceptions = member1_df[_TOPOEXCEPT].apply(lambda val: self.exception_mapping.get(val, frozenset()))
        # else:
        #     member1_exceptions = pd.Series([frozenset()] * len(member1_df), index=member1_df.index)
        #     # member1_topoexcept = pd.Series([False] * len(member1_df), index=member1_df.index, dtype=pd.BooleanDtype())
        #
        # error_df = error_df.merge(member1_df, how="left", left_on="OriginObjectID", right_on=oid1)
        #
        # if member2_df is not None:
        #     ...
        #
        # if _TOPOEXCEPT in member2_df.columns:
        #     ...

topology_config: _NG911TopologyConfig
"""The single instance of :class:`_NG911TopologyConfig` that contains the
topology rules specified in ``topology.yml``."""

with open(_TOPOLOGY_YAML_PATH) as yaml_file:
    topology_config = yaml.safe_load(yaml_file)

if __name__ == "__main__":
    breakpoint()